Enable running the VS Code extension host on the workspace (Node.js) by default#3093
Enable running the VS Code extension host on the workspace (Node.js) by default#3093joao-boechat wants to merge 17 commits intomainfrom
Conversation
|
Since web workers was touched, I just wanted to double-check that the worker logic responsible for state-viz calculation on the circuit editor was still functional. I tested it and it seems to be working fine, but is there any reason to be concerned about that? |
|
@ScottCarda-MS I don't see any of the worker code I touched being referenced there. I think that the worker code in state-viz is named that way because it is supposed to be agnostic (browser and worker), so it avoids using API that's only available in browser. But my modifications shouldn't affect that part! |
There was a problem hiding this comment.
Can these just live in main.ts ? I don't see a clear logical separation of what's reexported in main.ts vs what's reexported here, so I'm not sure if the separate file is necessary.
There was a problem hiding this comment.
you're right, I did this when we still had two entrypoints and it still made sense. Will add it back to main
| initOutputWindowLogger(); | ||
| } | ||
|
|
||
| log.info(`Platform: ${getPlatformEnv()}`); |
There was a problem hiding this comment.
Can we use log.debug or lower for this? info is enabled by default, and can get kind of noisy.
(These logs are pretty useful for testing this PR. Maybe you could clean up the logs just before merging.)
| @@ -1,5 +1,6 @@ | |||
| // Copyright (c) Microsoft Corporation. | |||
| // Licensed under the MIT License. | |||
| declare const __PLATFORM__: string; | |||
There was a problem hiding this comment.
To make this less error prone you may want to strictly type it like this since we hardcode the values anyway.
| declare const __PLATFORM__: string; | |
| declare const __PLATFORM__: "browser" | "node"; |
There was a problem hiding this comment.
Pull request overview
This PR switches the Q# VS Code extension to run its extension host primarily on the workspace side (Node.js) by default (while still supporting VS Code for Web), and refactors the qsharp-lang npm package to use a single, platform-agnostic entry point with a unified worker strategy.
Changes:
- Build and package the VS Code extension as separate browser and node bundles (plus node worker bundles), routing worker script paths via a
__PLATFORM__build-time constant. - Unify
qsharp-lang’s browser/node entry points intomain.ts, remove the separate Node.js wasm-bindgen target, and useweb-workerto abstract Worker differences. - Update npm tests and tooling to use the unified async APIs and explicit worker paths.
Reviewed changes
Copilot reviewed 36 out of 37 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| source/vscode/src/webviewPanel.ts | Uses platform-specific output path for compiler worker script. |
| source/vscode/src/telemetry.ts | Adds platform-aware user agent / “browser release” reporting for Node vs browser. |
| source/vscode/src/qirGeneration.ts | Uses platform-specific output path for compiler worker script. |
| source/vscode/src/extension.ts | Logs platform/UI kind/remote name during activation for diagnostics. |
| source/vscode/src/documentation.ts | Uses platform-specific output path for compiler worker script. |
| source/vscode/src/debugger/debug-service-worker.ts | Simplifies worker entry to side-effect import of qsharp-lang worker module. |
| source/vscode/src/debugger/activate.ts | Uses platform-specific output path for debug worker script. |
| source/vscode/src/compilerWorker.ts | Simplifies worker entry to side-effect import of qsharp-lang worker module. |
| source/vscode/src/common.ts | Introduces __PLATFORM__ and getPlatformEnv() for consistent path selection. |
| source/vscode/src/circuit.ts | Uses platform-specific output path for compiler worker script. |
| source/vscode/package.json | Sets desktop main to node bundle, web browser to browser bundle; adds extensionKind. |
| source/vscode/build.mjs | Builds browser/node/node-worker bundles; copies wasm and external node deps (web-worker). |
| source/npm/qsharp/test/languageService.js | Updates tests to preload wasm and await async service getters. |
| source/npm/qsharp/test/diagnostics.js | Updates tests for async compiler/loader getters and explicit worker paths. |
| source/npm/qsharp/test/circuits.js | Updates tests to preload wasm and await async compiler getter. |
| source/npm/qsharp/test/basics.js | Updates tests for async APIs and explicit worker paths for worker-based services. |
| source/npm/qsharp/src/workers/worker.ts | Keeps worker-side service initialization; removes proxy-creation from this module. |
| source/npm/qsharp/src/workers/node.ts | Removes legacy node-specific worker/proxy implementation. |
| source/npm/qsharp/src/workers/main.ts | Adds unified proxy creation using web-worker for both Node and browser. |
| source/npm/qsharp/src/main.ts | Unifies browser/node entrypoint; adds wasm loading/instantiation flow and async APIs. |
| source/npm/qsharp/src/log.ts | Updates description to reflect shared browser/Node logging infrastructure. |
| source/npm/qsharp/src/language-service/worker.ts | Switches to unified worker creation module; adds implicit message listener. |
| source/npm/qsharp/src/language-service/worker-node.ts | Removes legacy node worker entrypoint. |
| source/npm/qsharp/src/language-service/language-service.ts | Updates types/imports to align with unified entrypoint structure. |
| source/npm/qsharp/src/debug-service/worker.ts | Switches to unified worker creation module; adds implicit message listener. |
| source/npm/qsharp/src/debug-service/worker-node.ts | Removes legacy node worker entrypoint. |
| source/npm/qsharp/src/debug-service/debug-service.ts | Updates types/imports to align with unified entrypoint structure. |
| source/npm/qsharp/src/compiler/worker.ts | Switches to unified worker creation module; adds implicit message listener. |
| source/npm/qsharp/src/compiler/worker-node.ts | Removes legacy node worker entrypoint. |
| source/npm/qsharp/src/compiler/compiler.ts | Removes outdated comment about node-specific wasm types. |
| source/npm/qsharp/src/common-exports.ts | Adds shared re-export surface for unified entrypoint. |
| source/npm/qsharp/src/browser.ts | Removes legacy browser-only entrypoint. |
| source/npm/qsharp/README.md | Updates docs to describe single entry point and bundling expectations. |
| source/npm/qsharp/package.json | Simplifies exports to a single entry point; adds web-worker dependency. |
| source/npm/qsharp/generate_docs.js | Updates docs generation to initialize via web wasm glue. |
| package-lock.json | Adds web-worker dependency lock entries. |
| build.py | Removes nodejs wasm-bindgen target; builds/copies only web target artifacts. |
Comments suppressed due to low confidence (3)
source/npm/qsharp/src/compiler/worker.ts:9
messageHandleris exported for backwards compatibility, but this module also unconditionally registers it viaaddEventListener. Any existing consumers that still doself.onmessage = messageHandlerwill now process each message twice (confirmed insource/playground/src/compiler-worker.ts). Consider removing the implicit registration, or making registration idempotent / updating consumers so only one handler is attached.
source/npm/qsharp/src/language-service/worker.ts:9- Same issue as the compiler worker: exporting
messageHandlerwhile also registering it withaddEventListenercan lead to double-handling when older consumers assignself.onmessage = messageHandler(confirmed insource/playground/src/language-service-worker.ts). Prefer a single registration mechanism to preserve backwards compatibility.
source/npm/qsharp/src/debug-service/worker.ts:9 messageHandleris exported for backwards compatibility, but this module also unconditionally registers it withaddEventListener, which can lead to double-handling if any consumer also assignsself.onmessage = messageHandler. If backwards compatibility is required, prefer a single/explicit registration path or make registration idempotent.
| export function loadWasmModule( | ||
| uriOrBuffer: string | ArrayBuffer, | ||
| ): Promise<void> { |
There was a problem hiding this comment.
loadWasmModule is typed as accepting only string | ArrayBuffer, but callers in this repo pass a Uint8Array (e.g. vscode.workspace.fs.readFile(...)). The implementation works with any BufferSource, so the public type should be widened (e.g. string | BufferSource) to avoid TypeScript errors for valid usage.
| const wasmRequst = await fetch(uriOrBuffer); | ||
| const wasmBuffer = await wasmRequst.arrayBuffer(); |
There was a problem hiding this comment.
Minor typo: local variable wasmRequst looks like it should be wasmRequest for readability/searchability.
| const wasmRequst = await fetch(uriOrBuffer); | |
| const wasmBuffer = await wasmRequst.arrayBuffer(); | |
| const wasmRequest = await fetch(uriOrBuffer); | |
| const wasmBuffer = await wasmRequest.arrayBuffer(); |
| const ctx = await context({ | ||
| ...commonBuildOptions, | ||
| entryPoints: [ | ||
| join(thisDir, "src", "extension.ts"), | ||
| join(thisDir, "src", "compilerWorker.ts"), | ||
| join(thisDir, "src", "debugger/debug-service-worker.ts"), | ||
| join(thisDir, "src", "webview/webview.tsx"), | ||
| join(thisDir, "src", "webview/editor.tsx"), | ||
| ], | ||
| platform: "browser", | ||
| outdir: join(thisDir, "out", "web"), | ||
| plugins: [inlineStateComputeWorkerPlugin, buildPlugin], |
There was a problem hiding this comment.
build:watch outputs to out/web, but the extension manifest now points to out/browser/extension.js for the web entrypoint. This makes watch mode produce artifacts that VS Code won’t load. Align the watch outdir with the runtime entrypoints (e.g. out/browser and/or out/webview), or update the manifest/dev workflow accordingly.
| if (isWatch) { | ||
| watchVsCode(); | ||
| } else { | ||
| buildUI(); |
There was a problem hiding this comment.
I find it kind of convoluted the way multiple parameters (platform, onlyUI) are plumbed through multiple layers of function, only to arrive at some hardcoded build configuration objects anyway.
At the end of the day we're just calling esbuild.build 4 times in a row with different configs, right? Couldn't we just define those configs inline here, and call buildBundle() directly?
Or you could put the 4 distinct configs in an array at the top of the file, and call buildBundle in a loop. This way they'd also be right next to commonBuildOptions -- they're kind of hard to find buried in the middle of the file, so it's hard to tell how the different options combine.
| /** @type {import("esbuild").BuildOptions} */ | ||
| const buildOptions = { | ||
| const commonBuildOptions = { | ||
| entryPoints: [ |
There was a problem hiding this comment.
entrypoints is always overwritten, should it be defined in commonBuildOptions at all?
| * Copy external node dependencies into node_modules/ under the extension | ||
| * directory so they can be resolved at runtime (e.g. when installed as a VSIX). | ||
| */ | ||
| function copyNodeExternals() { |
There was a problem hiding this comment.
This is a bit weird. If web-worker is a dependency we should just declare it as a dependency in package.json so it can be properly installed into node_modules by the package manager.
That said, I don't know why we can't bundle web-worker for node, the same way we do for the browser?
Summary
This PR makes the Q# VS Code extension run its extension host primarily on the workspace side (Node.js) rather than on the UI side (browser). This is important for remote development scenarios — WSL, SSH, Codespaces, Dev Containers — where keeping the extension host close to the workspace avoids bugs and simplifies the architecture. The extension still supports running on the UI side for VS Code for Web.
To make this work, the Node.js entry point of the
qsharp-langnpm package needed to function correctly. Rather than fixing the separate Node.js codepath, we unified both entry points into a single platform-agnostic module, using theweb-workerpackage to abstract away Worker API differences between browsers and Node.js.Motivation
The Q# extension was originally built as a web-first extension, meaning the extension host always ran on the UI side (browser), even in desktop VS Code. This caused issues in remote development scenarios (WSL, SSH, Codespaces, Dev Containers) where the extension host needs to run close to the workspace for correct behavior. By making the Node.js entry point the default, the extension host runs on the workspace side, avoiding these issues.
Additionally, the npm package previously maintained two parallel codepaths —
browser.tsandmain.ts— each with its own wasm loading strategy, worker proxy implementation, and per-service worker scripts. The build system also produced two separate wasm-bindgen targets (webandnodejs) and used npm conditional exports to route consumers to the right entry point. The Node.js path was incomplete and buggy, and the duplication made changes error-prone. Unifying these into a single platform-agnostic entry point simplifies the codebase and eliminates this class of bugs.Architecture (of qsharp-lang)
Before
graph TD subgraph "npm package entry points" B["browser.ts (browser entry)"] M["main.ts (Node.js entry)"] end B --> WB["workers/browser.ts"] M --> WN["workers/node.ts"] WB --> CWB["compiler/worker-browser.ts"] WB --> LSWB["language-service/worker-browser.ts"] WB --> DSWB["debug-service/worker-browser.ts"] WN --> CWN["compiler/worker-node.ts"] WN --> LSWN["language-service/worker-node.ts"] WN --> DSWN["debug-service/worker-node.ts"] CWB --> WASMW["lib/web/qsc_wasm.js"] CWN --> WASMN["lib/nodejs/qsc_wasm.cjs"]After
graph TD subgraph "npm package entry point" M["main.ts (single entry)"] end M --> WM["workers/main.ts (proxy, uses web-worker pkg)"] WW["workers/worker.ts (runs inside worker)"] WW --> CW["compiler/worker.ts"] WW --> LSW["language-service/worker.ts"] WW --> DSW["debug-service/worker.ts"] CW --> WASM["lib/web/qsc_wasm.js"] LSW --> WASM DSW --> WASM WM -.->|spawns| CW WM -.->|spawns| LSW WM -.->|spawns| DSWChanges
qsharp-langnpm package (source/npm/qsharp/)browser.tsand merged all functionality intomain.ts— a single platform-agnostic entry point.worker-browser.ts/worker-node.ts) into a singleworker.tseach.workers/browser.tsandworkers/node.tswithworkers/worker.ts(worker-side) andworkers/main.ts(main-thread proxy using theweb-workerpackage).common-exports.tsfor shared re-exports.webwasm-bindgen target is used; thenodejstarget is no longer needed.package.jsonexports — removed conditionalbrowser/node/defaultrouting.web-worker(^1.5.0) as a dependency.async(getCompiler(),getDebugService(),getLanguageService(),getProjectLoader()), and worker factory functions now require an explicit worker path argument.VS Code extension (
source/vscode/)__PLATFORM__build-time constant ("browser"or"node") to resolve the correct worker script paths at runtime.extension.ts) and the worker entry points (compilerWorker.ts,debug-service-worker.ts).wasm/), which is the main reason the overall VSIX size only grew from 3.9 MB to 4.09 MB despite adding a full Node.js bundle.web-workerpackage must be marked as an external dependency in the Node.js esbuild bundle. This is becauseweb-workerperforms an internal runtime check that breaks if its code is inlined into the bundle. As a result, it is copied into the extension'snode_modules/so it can be resolved at runtime (e.g. when installed from a VSIX)../out/node/extension.js.Platform,UI Kind, andRemotename during activation for diagnostics.Build system (
build.py)webwasm-bindgen target is built. Removed thenodejstarget and its.js→.cjsrenaming logic.Tests (
source/npm/qsharp/test/)loadWasmModule, async service getters, explicit worker paths).Extra